SoFloC   A
last analyzed

Complexity

Total Complexity 7

Size/Duplication

Total Lines 110
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 7
eloc 79
dl 0
loc 110
rs 10
c 0
b 0
f 0
ccs 52
cts 52
cp 1

4 Functions

Rating   Name   Duplication   Size   Complexity  
A deleteFlow 0 18 1
A load 0 23 2
A updateVersion 0 18 1
A copyFlow 0 24 2
1 1
import { randomUUID } from 'crypto'
2 1
import JSZip from 'jszip'
3 1
import { xml2js } from 'xml-js'
4
import { CustomisationsXml } from './customisations'
5
import { SolutionXml } from './solution'
6
import { Base64, FileInput, FlowCopyT, PrivateWorkflowT, WorkflowT, Xml } from './types'
7
8 1
export class SoFloC {
9
  /**
10
   * Creates a new SoFloC instance. To be able to use it you need to run `await soFloC.load()`
11
   * @param file The file data to be open
12
   * @param name The name of the file
13
   */
14 22
  constructor (file: FileInput, name: string) {
15 22
    this.#wasLoaded = false
16 22
    this.#file = file
17 22
    this.name = name
18
  }
19
20
  /**
21
   * Loads a ***Solution*** zip file and make it ready to get the existing flows and the version, copy flows and update the version. Sets #wasLoaded to true
22
   */
23 25
  async load () {
24 25
    if (!this.#wasLoaded) {
25 21
      this.#zip = await this.#unzip(this.#file)
26
27 19
      const [customisations, customisationsData] = await this.#getCustomisations(this.#zip)
28 18
      this.#customisations = customisations
29 18
      this.#customisationsData = customisationsData
30
31 18
      const [solution, solutionData] = await this.#getSolution(this.#zip)
32 17
      this.#solution = solution
33 17
      this.#solutionData = solutionData
34
35 17
      this.version = this.#getCurrentVersion(this.#solutionData)
36 16
      this.originalVersion = this.version
37
38 16
      this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
39 16
      this.data = await this.#getData(this.#zip)
40
41 16
      this.#wasLoaded = true
42
    }
43
  }
44
45
  /**
46
   * Copies a flow in the ***Solution***.
47
   * @param flowGuid The GUID of the flow to be copied
48
   * @param newFlowName The name of the copy
49
   * @param newVersion The new ***Solution*** version
50
   */
51 7
  async copyFlow (flowGuid: string, newFlowName: string, newVersion?: string) {
52 7
    await this.load()
53 6
    this.#worflowExists(flowGuid)
54
55 3
    if (newVersion) await this.updateVersion(newVersion)
56
57 3
    const copyData = this.#getCopyData(newFlowName)
58
59 3
    const [customisations, customisationsData] = this.#copyOnCustomisations(flowGuid, copyData)
60 3
    this.#customisations = customisations
61 3
    this.#customisationsData = customisationsData
62
63 3
    const [solution, solutionData] = this.#copyOnSolution(flowGuid, copyData)
64 3
    this.#solution = solution
65 3
    this.#solutionData = solutionData
66
67 3
    await this.#copyFile(flowGuid, copyData)
68
  }
69
70
  /**
71
   * Deletes a flow in the ***Solution***.
72
   * @param flowGuid The GUID of the flow to be copied
73
   */
74 5
  async deleteFlow (flowGuid: string) {
75 5
    await this.load()
76 4
    this.#worflowExists(flowGuid)
77
78 1
    const [customisations, customisationsData] = this.#deleteOnCustomisations(flowGuid)
79 1
    this.#customisations = customisations
80 1
    this.#customisationsData = customisationsData
81
82 1
    const [solution, solutionData] = this.#deleteOnSolution(flowGuid)
83 1
    this.#solution = solution
84 1
    this.#solutionData = solutionData
85
86 1
    await this.#deleteFile(flowGuid)
87
  }
88
89
  /**
90
   * Updates the ***Solution*** version. The new version must be bigger than the previous.
91
   * @param newVersion The new ***Solution*** version
92
   */
93 13
  async updateVersion (newVersion: string) {
94 13
    await this.load()
95 10
    this.validateVersion(newVersion)
96
97 6
    this.name = this.name
98
      .replace(this.#snake(this.version), this.#snake(newVersion))
99 6
    this.#solution = this.#solution
100
      .replace(`<Version>${this.version}</Version>`, `<Version>${newVersion}</Version>`)
101 6
    this.version = newVersion
102
103 6
    this.#zip.file('solution.xml', this.#solution)
104
105 6
    this.data = await this.#getData(this.#zip)
106
  }
107
108
  /**
109
   * The list of workflows in the solution. To be able to get the list you need to run `await soFloC.load()` first.
110
   */
111 7
  get workflows () {
112 7
    if (!this.#wasLoaded) return []
113 17
    return this.#workflows.map(workflow => ({
114
      name: workflow.name,
115
      id:   workflow.id,
116
    })) as WorkflowT[]
117
  }
118
119
  /* #region LOAD METHODS */
120
  /**
121
   * Resets the loaded data
122
   */
123
  /**
124
   * Retrieves the ***Solution*** zip content
125
   * @param file The ***Solution*** zip file (base64, string, text, binarystring, array, uint8array, arraybuffer, blob or stream)
126
   */
127
  async #unzip (file: FileInput) {
128 21
    try {
129 21
      const options = typeof file === 'string'
130
        ? { base64: true }
131
        : {}
132 21
      return await JSZip.loadAsync(file, options)
133
    } catch (error) {
134 2
      console.log(error)
135 2
      throw new Error('Failed to unzip the file')
136
    }
137
  }
138
139
  /**
140
   * Retrieves the customization.xml string
141
   * @param zip The ***Solution*** JSZip content
142
   */
143
  async #getCustomisations (zip: JSZip): Promise<[Xml, CustomisationsXml]> {
144 19
    return (await this.#getXmlContentFromZip('customizations', zip)) as [Xml, CustomisationsXml]
145
  }
146
147
  /**
148
   * Retrieves the customization.xml string
149
   * @param zip The ***Solution*** JSZip content
150
   */
151
  async #getSolution (zip: JSZip): Promise<[Xml, SolutionXml]> {
152 18
    return (await this.#getXmlContentFromZip('solution', zip)) as [Xml, SolutionXml]
153
  }
154
155
  /**
156
   * Retrieves a XML from the ***Solution*** zip.
157
   * @param xmlName The name of the XML to be retrieved (without extension)
158
   * @returns The string content of the XML
159
   */
160
  async #getXmlContentFromZip (xmlName: string, zipContents: JSZip): Promise<[Xml, CustomisationsXml | SolutionXml]> {
161 37
    try {
162 37
      const file = zipContents.files[`${xmlName}.xml`]
163 37
      const xml = await file.async('string')
164 35
      const data = xml2js(xml, { compact: true }) as CustomisationsXml
165
166 35
      return [
167
        xml,
168
        data,
169
      ]
170
    } catch (error) {
171 2
      console.log(error)
172 2
      throw new Error(`'${xmlName}.xml' was not found in the Solution zip`)
173
    }
174
  }
175
176
  /**
177
   * Retrieves the ***Solution*** current version from solution.xml
178
   * @param solution The solution.xml
179
   */
180
  #getCurrentVersion (solution: SolutionXml) {
181 17
    try {
182 17
      return solution.ImportExportXml.SolutionManifest.Version._text
183
    } catch (error) {
184 1
      console.log(error)
185 1
      throw new Error('Failed to retrieve the version')
186
    }
187
  }
188
189
  /**
190
   * Retrieves the list of workflows found in the ***Solution*** zip
191
   * @param customisations The customizations.xml
192
   * @param zip The ***Solution*** JSZip content
193
   * @returns The workflows list
194
   */
195
  #getWorkflows (customisations: CustomisationsXml, solution: SolutionXml, zip: JSZip) {
196 43
    const workflowFiles = Object.entries(zip.files).filter(([name]) => name.match(/Workflows\/.+\.json/)).map(file => file[1])
197
198 20
    const wfs = Array.isArray(customisations.ImportExportXml.Workflows.Workflow)
199
      ? customisations.ImportExportXml.Workflows.Workflow
200
      : [customisations.ImportExportXml.Workflows.Workflow]
201 20
    const workflows = wfs
202 45
      .map(workflow => {
203 45
        const id = workflow._attributes.WorkflowId.replace(/{|}/g, '')
204 45
        const rcs = Array.isArray(solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent)
205
          ? solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent
206
          : [solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent]
207 84
        const isOnSolution = rcs.findIndex(wf => wf._attributes.id.includes(id)) >= 0
208 80
        const file = workflowFiles.find(workflowFile => workflowFile.name.includes(id.toUpperCase())) as JSZip.JSZipObject
209 45
        return !!file && !!id && isOnSolution
210
          ? {
211
              name: workflow._attributes.Name,
212
              id,
213
              file,
214
            }
215
          : null
216
      })
217 45
    return workflows.filter(workflow => workflow !== null) as PrivateWorkflowT[]
218
  }
219
220
  /**
221
   * Retrieves the zip data
222
   * @param zip The ***Solution*** zip
223
   * @returns The generated base64 zip
224
   */
225
  async #getData (zip: JSZip) {
226 26
    return await zip.generateAsync({
227
      type:               'base64',
228
      compression:        'DEFLATE',
229
      compressionOptions: {
230
        level: 9,
231
      },
232
    })
233
  }
234
  /* #endregion */
235
236
  /* #region COPY FLOW METHODS */
237
  /**
238
   * Retrieves an object containing the information of the flow copy
239
   * @param newFlowName The name of the flow copy
240
   * @returns The flow copy data
241
   */
242
  #getCopyData (newFlowName: string) {
243 3
    const guid = randomUUID()
244 3
    const upperGuid = guid.toUpperCase()
245 3
    const fileName = `Workflows/${newFlowName.replace(/\s/g, '')}-${upperGuid}.json`
246
247 3
    return {
248
      guid,
249
      upperGuid,
250
      name: newFlowName,
251
      fileName,
252
    }
253
  }
254
255
  /**
256
   * Copies the flow inside the customizations.xml
257
   * @param flowGuid The GUID of the original flow to be copied
258
   * @param copyData The data of the flow copy
259
   * @returns The customisations.xml data
260
   */
261
  #copyOnCustomisations (flowGuid: string, copyData: FlowCopyT): [Xml, CustomisationsXml] {
262 4
    const flow = this.workflows.find(wf => wf.id === flowGuid) as WorkflowT
263 3
    const workflowComponent = `<Workflow WorkflowId="{${flowGuid}}" Name=".+?">(.|\r|\n)+?<\/Workflow>`
264 3
    const workflowRegEx = new RegExp(`\r?\n?.+?${workflowComponent}`, 'gm')
265
266 3
    const workflow = this.#customisations.match(workflowRegEx)?.[0] as string
267
268 3
    const jsonFileNameRegEx = /<JsonFileName>(.|\r|\n)+?<\/JsonFileName>/gi
269 3
    const introducedVersionRegEx = /<IntroducedVersion>(.|\r|\n)+?<\/IntroducedVersion>/gi
270 3
    const localisedNameComponent = `<LocalizedName languagecode="(\\d+?)" description="${flow.name}" />`
271 3
    const localisedNameRegEx = new RegExp(localisedNameComponent)
272
273 3
    const copy = workflow
274
      .replace(flowGuid, copyData.guid)
275
      .replace(/Name=".+?"/, `Name="${copyData.name}"`)
276
      .replace(jsonFileNameRegEx, `<JsonFileName>/${copyData.fileName}</JsonFileName>`)
277
      .replace(introducedVersionRegEx, `<IntroducedVersion>${this.version}</IntroducedVersion>`)
278
      .replace(localisedNameRegEx, `<LocalizedName languagecode="$1" description=\"${copyData.name}\" \/>`)
279
280 3
    const customisations = this.#customisations.replace(workflow, `${workflow}${copy}`)
281 3
    const data = xml2js(customisations, { compact: true }) as CustomisationsXml
282
283 3
    return [
284
      customisations,
285
      data,
286
    ]
287
  }
288
289
  /**
290
   * Copies the flow inside solution.xml
291
   * @param flowGuid The GUID of the original flow to be copied
292
   * @param copyData The data of the flow copy
293
   * @returns The solution.xml data
294
   */
295
  #copyOnSolution (flowGuid: string, copyData: FlowCopyT): [Xml, SolutionXml] {
296 3
    const rootComponent = `<RootComponent type="29" id="{${flowGuid}}" behavior="0" />`
297 3
    const rootRegEx = new RegExp(`\r?\n?.+?${rootComponent}`, 'gm')
298
299 3
    const root = this.#solution.match(rootRegEx)?.[0] as string
300
301 3
    const copy = root
302
      .replace(flowGuid, copyData.guid)
303
304 3
    const solution = this.#solution
305
      .replace(root, `${root}${copy}`)
306 3
    const data = xml2js(solution, { compact: true }) as SolutionXml
307
308 3
    return [
309
      solution,
310
      data,
311
    ]
312
  }
313
314
  /**
315
   * Copies the flow inside the ***Solution*** zip and updates data and #workflows properties
316
   * @param flowGuid The GUID of the original flow to be copied
317
   * @param copyData The data of the flow copy
318
   */
319
  async #copyFile (flowGuid: string, copyData: FlowCopyT) {
320 4
    const fileToCopy = this.#workflows.find(wf => wf.id === flowGuid.toLowerCase()) as PrivateWorkflowT
321
322 3
    this.#zip.file(copyData.fileName, await fileToCopy.file.async('string'))
323 3
    this.#zip.file('solution.xml', this.#solution)
324 3
    this.#zip.file('customizations.xml', this.#customisations)
325
326 3
    this.data = await this.#getData(this.#zip)
327 3
    this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
328
  }
329
  /* #endregion */
330
331
  /* #region DELETE FLOW METHODS */
332
  /**
333
   * Deletes the flow inside the customizations.xml
334
   * @param flowGuid The GUID of the flow to be deleted
335
   * @returns The customisations.xml data
336
   */
337
  #deleteOnCustomisations (flowGuid: string): [Xml, CustomisationsXml] {
338 1
    const workflowComponent = `<Workflow WorkflowId="{${flowGuid}}" Name=".+?">(.|\r|\n)+?<\/Workflow>`
339 1
    const workflowRegEx = new RegExp(`\r?\n?.+?${workflowComponent}`, 'gm')
340
341 1
    const workflow = this.#customisations.match(workflowRegEx)?.[0] as string
342
343 1
    const customisations = this.#customisations.replace(workflow, '')
344 1
    const data = xml2js(customisations, { compact: true }) as CustomisationsXml
345
346 1
    return [
347
      customisations,
348
      data,
349
    ]
350
  }
351
352
  /**
353
   * Deletes the flow inside solution.xml
354
   * @param flowGuid The GUID of the flow to be deleted
355
   * @returns The solution.xml data
356
   */
357
  #deleteOnSolution (flowGuid: string): [Xml, SolutionXml] {
358 1
    const rootComponent = `<RootComponent type="29" id="{${flowGuid}}" behavior="0" />`
359 1
    const rootRegEx = new RegExp(`\r?\n?.+?${rootComponent}`, 'gm')
360
361 1
    const root = this.#solution.match(rootRegEx)?.[0] as string
362
363 1
    const solution = this.#solution.replace(root, '')
364 1
    const data = xml2js(solution, { compact: true }) as SolutionXml
365
366 1
    return [
367
      solution,
368
      data,
369
    ]
370
  }
371
372
  /**
373
   * Deletes the flow inside the ***Solution*** zip and updates data and #workflows properties
374
   * @param flowGuid The GUID of the flow to be deleted
375
   */
376
  async #deleteFile (flowGuid: string) {
377 2
    const fileToDelete = this.#workflows.find(wf => wf.id === flowGuid.toLowerCase()) as PrivateWorkflowT
378
379 1
    this.#zip.remove(fileToDelete.file.name)
380 1
    this.#zip.file('solution.xml', this.#solution)
381 1
    this.#zip.file('customizations.xml', this.#customisations)
382
383 1
    this.data = await this.#getData(this.#zip)
384 1
    this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
385
  }
386
  /* #endregion */
387
388
  /* #region UPDATE VERION METHODS */
389
  /**
390
   * Validates if the new version is valid
391
   * @param newVersion The new ***Solution*** version
392
   */
393 10
  validateVersion (newVersion: string) {
394 10
    const validRegEx = /^((\d+\.)+\d+)$/
395 10
    if (!validRegEx.exec(newVersion)) {
396 1
      throw new Error(`Version '${newVersion}' is not valid. It should follow the format <major>.<minor>.<?build>.<?revision>.`)
397
    }
398
399 36
    const originalVersionValues = this.originalVersion.split('.').map(value => Number(value))
400 28
    const newVersionValues = newVersion.split('.').map(value => Number(value))
401
402 9
    let currentValueString = ''
403 9
    let newValueString = ''
404 9
    for (let i = 0; i < originalVersionValues.length; i++) {
405 36
      const currentValue = originalVersionValues[i] || 0
406 36
      const newValue = newVersionValues[i] || 0
407
408 36
      const currentValueLength = String(currentValue).length
409 36
      const newValueLength = String(newValue).length
410
411 36
      const maxLength = Math.max(currentValueLength, newValueLength)
412
413 36
      currentValueString += '0'.repeat(maxLength - currentValueLength) + String(currentValue)
414 36
      newValueString += '0'.repeat(maxLength - newValueLength) + String(newValue)
415
    }
416
417 9
    if (Number(newValueString) < Number(currentValueString) ||
418 3
    (Number(newValueString) === Number(currentValueString) && newVersion !== this.originalVersion)) throw new Error(`Version '${newVersion}' is smaller than '${this.originalVersion}'`)
419
  }
420
  /* #endregion */
421
422
  /* #region  GENERAL METHODS */
423
  /**
424
   * Verifies if a specified workflow exists in the ***Solution***
425
   */
426
  #worflowExists (flowGuid: string) {
427 12
    if (this.#workflows.findIndex(wf => wf.id === flowGuid) < 0) throw new Error(`Workflow file with GUID '${flowGuid}' does not exist in this Solution or the Solution was changed without updating 'solution.xml' or 'customizations.xml'`)
428
  }
429
430
  /**
431
   * Retrieves the version replacing '.' to '_'
432
   * @param version The version to be converted to snake_case
433
   * @returns
434
   */
435
  #snake (version: string) {
436 12
    return version.replaceAll('.', '_')
437
  }
438
  /* #endregion */
439
440
  /* #region CLASS PROPERTIES */
441
  #file: FileInput
442
  #zip: JSZip
443
  /**
444
   * The ***Solution*** file name. It is update as a new version is set.
445
   */
446
  name: string
447
  /**
448
   * The ***Solution*** version. It is update as a new version is set.
449
   */
450
  version: string
451
  /**
452
   * The ***Solution*** data as Base64. It is updated as new copies are added.
453
   */
454
  data: Base64
455
  /**
456
   * The ***Solution*** version as it was when the file was loaded. It does not change when a new version is set.
457
   */
458
  originalVersion: string
459
  #workflows: PrivateWorkflowT[]
460
  #customisations: Xml
461
  #customisationsData: CustomisationsXml
462
  #solution: Xml
463
  #solutionData: SolutionXml
464 22
  #wasLoaded = false
465
466
  // TODO UndoStack
467
468
  /* #endregion */
469
}
470